﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Windows.Forms;

/// <summary>
///  Represents a radio button control of a task dialog.
/// </summary>
public sealed class TaskDialogRadioButton : TaskDialogControl
{
    private string? _text;
    private int _radioButtonID;
    private bool _enabled = true;
    private bool _checked;
    private TaskDialogRadioButtonCollection? _collection;
    private bool _ignoreRadioButtonClickedNotification;

    /// <summary>
    ///  Occurs when the value of the <see cref="Checked"/> property changes
    ///  while this control is shown in a task dialog.
    /// </summary>
    public event EventHandler? CheckedChanged;

    /// <summary>
    ///  Initializes a new instance of the <see cref="TaskDialogRadioButton"/> class.
    /// </summary>
    public TaskDialogRadioButton()
    {
    }

    /// <summary>
    ///  Initializes a new instance of the <see cref="TaskDialogRadioButton"/> class
    ///  using the given <paramref name="text"/>.
    /// </summary>
    public TaskDialogRadioButton(string? text)
        : this()
    {
        _text = text;
    }

    /// <summary>
    ///  Gets or sets a value indicating whether the button can respond to user interaction.
    /// </summary>
    /// <value>
    ///  <see langword="true"/> if the button can respond to user interaction; otherwise,
    ///  <see langword="false"/>. The default value is <see langword="true"/>.
    /// </value>
    /// <remarks>
    ///  <para>
    ///   This property can be set while the dialog is shown.
    ///  </para>
    /// </remarks>
    /// <exception cref="InvalidOperationException">
    ///  The property is set on a radio button that is currently bound to a task dialog, but the dialog
    ///  has just started navigating to a different page.
    /// </exception>
    public bool Enabled
    {
        get => _enabled;
        set
        {
            if (CanUpdate())
            {
                BoundPage!.BoundDialog!.SetRadioButtonEnabled(_radioButtonID, value);
            }

            _enabled = value;
        }
    }

    /// <summary>
    ///  Gets or sets the text associated with this control.
    /// </summary>
    /// <value>
    ///  The text associated with this control. The default value is <see langword="null"/>.
    /// </value>
    /// <remarks>
    ///  <para>
    ///   This property must not be <see langword="null"/> or an empty string when showing or navigating
    ///   the dialog; otherwise the operation will fail.
    ///  </para>
    /// </remarks>
    /// <exception cref="InvalidOperationException">
    ///  The property is set and this radio button instance is currently bound to a task dialog.
    /// </exception>
    public string? Text
    {
        get => _text;
        set
        {
            DenyIfBound();

            _text = value;
        }
    }

    /// <summary>
    ///  Gets or set a value indicating whether the <see cref="TaskDialogRadioButton"/> is
    ///  in the checked state.
    /// </summary>
    /// <value>
    ///  <see langword="true"/> if the <see cref="TaskDialogRadioButton"/> is in the checked state;
    ///  otherwise, <see langword="false"/>. The default value is <see langword="false"/>.
    /// </value>
    /// <remarks>
    ///  <para>
    ///   While the dialog is shown, this property can only be set to <see langword="true"/> and you cannot
    ///   set it from within the <see cref="CheckedChanged"/> event.
    ///  </para>
    /// </remarks>
    /// <exception cref="InvalidOperationException">
    ///  The property is set and the task dialog has started navigating to a new page containing this radio button instance,
    ///  but the <see cref="TaskDialogPage.Created"/> event has not been raised yet.
    ///  - or -
    ///  The property is set on a radio button instance that is currently bound to a task dialog,
    ///  but the value to be set is <see langword="false"/>.
    ///  - or -
    ///  The property is set within the <see cref="CheckedChanged"/> event of one of the radio buttons of the
    ///  currently bound task dialog.
    ///  - or -
    ///  The property is set on a radio button instance that is currently bound to a task dialog, but the dialog
    ///  has just started navigating to a different page.
    /// </exception>
    public bool Checked
    {
        get => _checked;
        set
        {
            DenyIfBoundAndNotCreated(); // Shouldn't throw here as the control must have been created.
            DenyIfWaitingForInitialization();

            if (BoundPage is null)
            {
                _checked = value;

                // If we are part of a collection, set the checked value of all
                // all other buttons to False.
                // Note that this does not handle buttons that are added later
                // to the collection.
                if (_collection is not null && value)
                {
                    foreach (TaskDialogRadioButton radioButton in _collection)
                    {
                        radioButton._checked = radioButton == this;
                    }
                }

                return;
            }

            // Unchecking a radio button is not possible in the task dialog.
            // TODO: Should we throw only if the new value is different than the
            // old one?
            if (!value)
            {
                throw new InvalidOperationException(SR.TaskDialogCannotUncheckRadioButtonWhileBound);
            }

            // Note: We do not allow to set the "Checked" property of any
            // radio button of the current task dialog while we are within
            // the TDN_RADIO_BUTTON_CLICKED notification handler. This is
            // because the logic of the task dialog is such that the radio
            // button will be selected AFTER the callback returns (not
            // before it is called), at least when the event is caused by
            // code sending the TDM_CLICK_RADIO_BUTTON message. This is
            // mentioned in the documentation for TDM_CLICK_RADIO_BUTTON:
            // "The specified radio button ID is sent to the
            // TaskDialogCallbackProc callback function as part of a
            // TDN_RADIO_BUTTON_CLICKED notification code. After the
            // callback function returns, the radio button will be
            // selected."
            //
            // While we handle this by ignoring the
            // TDN_RADIO_BUTTON_CLICKED notification when it is caused by
            // sending a TDM_CLICK_RADIO_BUTTON message, and then raising
            // the events after the notification handler returned, this
            // still seems to cause problems for TDN_RADIO_BUTTON_CLICKED
            // notifications initially caused by the user clicking the radio
            // button in the UI.
            //
            // For example, consider a scenario with two radio buttons
            // [ID 1 and 2], and the user added an event handler to
            // automatically select the first radio button (ID 1) when the
            // second one (ID 2) is selected in the UI.
            // This means the stack will then look as follows:
            // Show() ->
            // Callback: TDN_RADIO_BUTTON_CLICKED [ID 2] ->
            // SendMessage: TDM_CLICK_RADIO_BUTTON [ID 1] ->
            // Callback: TDN_RADIO_BUTTON_CLICKED [ID 1]
            //
            // However, when the initial TDN_RADIO_BUTTON_CLICKED handler
            // (ID 2) returns, the task dialog again calls the handler for
            // ID 1 (which wouldn't be a problem), and then again calls it
            // for ID 2, which is unexpected (and it doesn't seem that we
            // can prevent this by returning S_FALSE in the notification
            // handler). Additionally, after that it even seems we get an
            // endless loop of TDN_RADIO_BUTTON_CLICKED notifications even
            // when we don't send any further messages to the dialog.
            // See documentation/repro in
            // /docs/src/System/Windows/Forms/TaskDialog/Issue_RadioButton_InfiniteLoop.md
            //
            // See also:
            // /docs/src/System/Windows/Forms/TaskDialog/Issue_RadioButton_WeirdBehavior.md
            if (BoundPage.BoundDialog!.RadioButtonClickedStackCount > 0)
            {
                throw new InvalidOperationException(string.Format(
                    SR.TaskDialogCannotSetRadioButtonCheckedWithinCheckedChangedEvent,
                    $"{nameof(TaskDialogRadioButton)}.{nameof(Checked)}",
                    $"{nameof(TaskDialogRadioButton)}.{nameof(CheckedChanged)}"));
            }

            // Click the radio button which will (recursively) raise the
            // TDN_RADIO_BUTTON_CLICKED notification. However, we ignore
            // the notification and then raise the events afterwards - see
            // above.
            _ignoreRadioButtonClickedNotification = true;
            try
            {
                BoundPage.BoundDialog.ClickRadioButton(_radioButtonID);
            }
            finally
            {
                _ignoreRadioButtonClickedNotification = false;
            }

            // Now raise the events.
            // Note: We also increment the stack count here to prevent
            // navigating the dialog and setting the Checked property
            // within the event handlers here even though this would work
            // correctly for the native API (as we are not in the
            // TDN_RADIO_BUTTON_CLICKED notification), because we are
            // raising two events (Unchecked+Checked), and when the
            // second event is called, the dialog might already be
            // navigated or another radio button may have been checked.
            TaskDialog boundDialog = BoundPage.BoundDialog;
            checked
            {
                boundDialog.RadioButtonClickedStackCount++;
            }

            try
            {
                HandleRadioButtonClicked();
            }
            finally
            {
                boundDialog.RadioButtonClickedStackCount--;
            }
        }
    }

    internal int RadioButtonID => _radioButtonID;

    internal TaskDialogRadioButtonCollection? Collection
    {
        get => _collection;
        set => _collection = value;
    }

    /// <summary>
    ///  Returns a string that represents the current <see cref="TaskDialogRadioButton"/> control.
    /// </summary>
    /// <returns>A string that contains the control text.</returns>
    public override string ToString() => _text ?? base.ToString() ?? string.Empty;

    internal TASKDIALOG_FLAGS Bind(TaskDialogPage page, int radioButtonID)
    {
        TASKDIALOG_FLAGS result = Bind(page);
        _radioButtonID = radioButtonID;

        return result;
    }

    internal void HandleRadioButtonClicked()
    {
        // Check if we need to ignore the notification when it is caused by
        // sending the TDM_CLICK_RADIO_BUTTON message.
        if (_ignoreRadioButtonClickedNotification)
        {
            return;
        }

        if (_checked)
        {
            return;
        }

        _checked = true;

        // Before raising the CheckedChanged event for the current button,
        // uncheck the other radio buttons and call their events (there
        // should be no more than one other button that is already checked).
        foreach (TaskDialogRadioButton radioButton in BoundPage!.RadioButtons)
        {
            if (radioButton != this && radioButton._checked)
            {
                radioButton._checked = false;
                radioButton.OnCheckedChanged(EventArgs.Empty);
            }
        }

        // Finally, call the event for the current button.
        OnCheckedChanged(EventArgs.Empty);
    }

    private protected override void UnbindCore()
    {
        _radioButtonID = 0;

        base.UnbindCore();
    }

    private protected override void ApplyInitializationCore()
    {
        // Re-set the properties so they will make the necessary calls.
        if (!_enabled)
        {
            Enabled = _enabled;
        }
    }

    private bool CanUpdate()
    {
        // Only update the button when bound to a task dialog, the button has actually been
        // created, and we are not waiting for the Navigated event. In the latter case we
        // don't throw an exception however, because ApplyInitialization() will be called in
        // the Navigated handler that does the necessary updates.
        return BoundPage?.WaitingForInitialization == false && IsCreated;
    }

    private void OnCheckedChanged(EventArgs e) => CheckedChanged?.Invoke(this, e);
}
